None
Вы работаете в стартапе, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи вашего мобильного приложения. Изучите воронку продаж. Узнайте, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах? На каких именно?
После этого исследуйте результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Выясните, какой шрифт лучше.
Каждая запись в логе — это действие пользователя, или событие.
EventName — название события;DeviceIDHash — уникальный идентификатор пользователя;EventTimestamp — время события;ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.Загрузка библиотек
import pandas as pd
from datetime import datetime, timedelta
from scipy import stats as st
import math as mth
from matplotlib import pyplot as plt
from plotly import graph_objects as go
#загрузка с обработкой ошибок
try:
df = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
except FileNotFoundError:
df = pd.read_csv('pr10/logs_exp.csv', sep='\t')
print('Общий вид данных: ')
display(df.head(5))
df.info()
print('--------------------------------------------------')
print('Полных дубликатов:', df.duplicated().sum())
print('--------------------------------------------------')
Общий вид данных:
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB -------------------------------------------------- Полных дубликатов: 413 --------------------------------------------------
print(f'Доля дубликатов: {df.duplicated().sum()/len(df):.2%}')
Доля дубликатов: 0.17%
Пустот нет. Дубликаты есть. Дата/время в нечитаемом формате. Названия столбцов неудобные.
Удаляем дубликаты. Меняем названия столбцов. Переводим формат времени в юзабельный.
Также посмотрим на "перебегания" между группами и уникальные события.
#Удаляем дубликаты
df = df[~df.duplicated()].reset_index(drop=True)
#Названия колонок исправляем
df.columns = df.columns.str.lower().str.replace(' ', '_')
#Приводим дату в нужный формат
df['eventtimestamp'] = pd.to_datetime(df['eventtimestamp'], unit='s')
#Проверяем есть ли пересечения в группах
display(df.groupby(
'deviceidhash', as_index=False).agg(
{'expid':'nunique'}).sort_values('expid', ascending=False).head(5))
#Смотрим уникальные события
display(df['eventname'].unique())
| deviceidhash | expid | |
|---|---|---|
| 0 | 6888746892508752 | 1 |
| 5030 | 6207768971558512760 | 1 |
| 5042 | 6217807653094995999 | 1 |
| 5041 | 6217295124800833842 | 1 |
| 5040 | 6216080220799726690 | 1 |
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear',
'OffersScreenAppear', 'Tutorial'], dtype=object)
#Отдельный столбец дат
df['date'] = pd.to_datetime(df['eventtimestamp']).dt.date
df['date'] = pd.to_datetime(df['date'])
print('Всего событий в логе:', len(df['eventname']))
print('Всего пользователей в логе:', len(df['deviceidhash'].unique()))
print('В среднем событий приходится на пользователя:', len(df['eventname'])/len(df['deviceidhash'].unique()))
#Найдем интервал дат
print('Минимальная дата события:', df['date'].min())
print('Максимальная дата события:', df['date'].max())
ax= df['date'].hist(bins=70, xrot=90, figsize=(14,4), legend=True)
df['eventtimestamp'].hist(ax=ax, bins=70, alpha=0.5, legend=True)
plt.xlabel('Дата/время событий')
plt.ylabel('Кол-во событий')
plt.title('Гистограммы событий по дате/времени');
Всего событий в логе: 243713 Всего пользователей в логе: 7551 В среднем событий приходится на пользователя: 32.27559263673685 Минимальная дата события: 2019-07-25 00:00:00 Максимальная дата события: 2019-08-07 00:00:00
По гистограмме заметно, что полные данные имеем только за одну неделю из двух. Посмотрим подробнее неполные данные и "наезд" по времени по-другому:
print('Уникальные клиенты, увидевшие Главную страницу:', df.query('eventname == "MainScreenAppear"')['deviceidhash'].nunique())
#Почасовое смещение событий
pd.to_datetime(df.query('date < "2019-08-01 00:00:00"')['eventtimestamp']).dt.hour.hist(bins=60, alpha=0.5, legend=True)
plt.title('Почасовое распределение событий для старых и новых данных')
plt.xlabel('Час событий в сутках')
plt.ylabel('Кол-во событий')
plt.show();
pd.to_datetime(df.query('date > "2019-07-31 00:00:00"')['eventtimestamp']).dt.hour.hist(bins=60, alpha=0.5, legend=True)
plt.xlabel('Час событий в сутках')
plt.ylabel('Кол-во событий')
plt.show();
Уникальные клиенты, увидевшие Главную страницу: 7439
Удивительно, но больше 100 пользователей из 7500, т.е. 1,3% не видели Главной страницы. Имеются и потери в регистрации событий. Учитывая, что мы имеем UTC-время, вероятно, что распределение событий в сутках для первой недели выглядит странным.
Посмотрим как много мы потеряем, отбросив старые данные?
#Распределение по группам для всех данных
display(df.groupby('expid').agg({'deviceidhash':'nunique'}))
print('Старых событий: ', len(df.query('date < "2019-08-01 00:00:00"')))
print('Относящихся к ', df.query('date < "2019-08-01 00:00:00"')['deviceidhash'].nunique(), 'пользователям')
print()
print('Распределение по группам на данных последней недели')
display(df.query('date > "2019-07-31 00:00:00"').groupby('expid').agg({'deviceidhash':'nunique'}))
| deviceidhash | |
|---|---|
| expid | |
| 246 | 2489 |
| 247 | 2520 |
| 248 | 2542 |
Старых событий: 2826 Относящихся к 1451 пользователям Распределение по группам на данных последней недели
| deviceidhash | |
|---|---|
| expid | |
| 246 | 2484 |
| 247 | 2513 |
| 248 | 2537 |
pr_hash = (df['deviceidhash'].nunique() - df.query('date > "2019-07-31 00:00:00"')['deviceidhash'].nunique())/df['deviceidhash'].nunique()
pr_event = (len(df) - len(df.query('date > "2019-07-31 00:00:00"')))/len(df)
print(f'Доля пользователей в "старых" данных: {pr_hash:.2%}')
print(f'Доля событий в "старых" данных: {pr_event:.2%}')
Доля пользователей в "старых" данных: 0.23% Доля событий в "старых" данных: 1.16%
Всего - ничего. Есть пользователи из всех трёх экспериментальных групп и пропорции остались. Ну, потерям по 2 события на 1/5 часть пользователей. Будет чуть больше тех, кто сразу на Акционную страницу попал, минуя Главную. Общие потери меньше 2%.
Осталяем последнююю неделю
df = df.query('date > "2019-07-31 00:00:00"').reset_index(drop=True)
df.groupby('eventname').agg({'expid':'count'}).sort_values('expid', ascending=False)
| expid | |
|---|---|
| eventname | |
| MainScreenAppear | 117328 |
| OffersScreenAppear | 46333 |
| CartScreenAppear | 42303 |
| PaymentScreenSuccessful | 33918 |
| Tutorial | 1005 |
uniq_hash = df['deviceidhash'].nunique()
df_funnel = df.groupby('eventname', as_index=False).agg(
{'deviceidhash':['nunique', lambda x : x.nunique()/uniq_hash*100]}).sort_values(
('deviceidhash', 'nunique'), ascending=False)
df_funnel.columns = ['eventname','hash_uniq','all_ratio']
df_funnel.style.format({'all_ratio': '{:.1f}%'})
| eventname | hash_uniq | all_ratio | |
|---|---|---|---|
| 1 | MainScreenAppear | 7419 | 98.5% |
| 2 | OffersScreenAppear | 4593 | 61.0% |
| 0 | CartScreenAppear | 3734 | 49.6% |
| 3 | PaymentScreenSuccessful | 3539 | 47.0% |
| 4 | Tutorial | 840 | 11.1% |
Вот и 1,5% разминувшихся с Главной страницей. Последовательность с 1 по 4 понятна. А что за загадочное Руководство? Большинство предпочитает по 15 раз открывать-закрывать Главную страницу и только единицы повторно заходят в Руководство. Скорее всего это необязательная опция в процессе покупки, типа "Правила отъема денег за непоставленный товар" на странице оплаты. Ну кто же это читает до оплаты? А тем более зачем это читать после? Или, может, это награда за 50 открываний Главной страницы?
Понятнее на следующем графике:
plt.figure(figsize=(14, 5))
for group in df['eventname'].unique():
plt.hist(df.query('eventname == @group')['eventtimestamp'], histtype='step', bins=150, alpha=0.7, label=group)
plt.legend(loc='upper center')
plt.title('Распределение событий во времени по типам')
plt.xlabel('Дата/время событий')
plt.ylabel('Кол-во событий')
plt.show();
Здесь видна последовательность событий и независимое событие Tutorial, которое стоит исключить из расчетов воронки.
Посчитаем пошаговую воронку и визуализируем.
df_funnel['prev_ratio'] = 100
df_funnel = df_funnel.reset_index(drop=True)
for i in range(1, len(df_funnel)):
df_funnel.loc[i,'prev_ratio'] = df_funnel['hash_uniq'][i]/df_funnel['hash_uniq'][i-1]*100
display(df_funnel.head(4).style.format({'all_ratio': '{:.1f}%', 'prev_ratio': '{:.1f}%'}))
fig = go.Figure(go.Funnel(y=df_funnel[:4]['eventname'], x=df_funnel[:4]['hash_uniq']))
fig.show()
| eventname | hash_uniq | all_ratio | prev_ratio | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 98.5% | 100.0% |
| 1 | OffersScreenAppear | 4593 | 61.0% | 61.9% |
| 2 | CartScreenAppear | 3734 | 49.6% | 81.3% |
| 3 | PaymentScreenSuccessful | 3539 | 47.0% | 94.8% |
Больше всего (40%) отсеиваются на шаге от Главной страницы до Акционного предложения. С начала до оплаты доходят 47% пользователей.
#Контрольная группа A1 (246)
uniq_hash246 = df.query('expid == 246')['deviceidhash'].nunique()
A246_funnel = df.query('expid == 246').groupby('eventname', as_index=False).agg(
{'deviceidhash':['nunique', lambda x : x.nunique()/uniq_hash246]}).sort_values(
('deviceidhash', 'nunique'), ascending=False)
A246_funnel.columns = ['eventname','uniq246','A246_ratio']
A246_funnel = A246_funnel.reset_index(drop=True)
#Контрольная группа A2 (247)
uniq_hash247 = df.query('expid == 247')['deviceidhash'].nunique()
A247_funnel = df.query('expid == 247').groupby('eventname', as_index=False).agg(
{'deviceidhash':['nunique', lambda x : x.nunique()/uniq_hash247]}).sort_values(
('deviceidhash', 'nunique'), ascending=False)
A247_funnel.columns = ['eventname','uniq247','A247_ratio']
A247_funnel = A247_funnel.reset_index(drop=True)
#Экспериментальная группа B (248)
uniq_hash248 = df.query('expid == 248')['deviceidhash'].nunique()
B248_funnel = df.query('expid == 248').groupby('eventname', as_index=False).agg(
{'deviceidhash':['nunique', lambda x : x.nunique()/uniq_hash248]}).sort_values(
('deviceidhash', 'nunique'), ascending=False)
B248_funnel.columns = ['eventname','uniq248','B248_ratio']
B248_funnel = B248_funnel.reset_index(drop=True)
#Объединяем воронки
print(f'Пользователей в каждой группе: A246 - {uniq_hash246}, A247 - {uniq_hash247}, B248 - {uniq_hash248}')
all_funnels = df_funnel.merge(A246_funnel, on='eventname').merge(A247_funnel, on='eventname').merge(B248_funnel, on='eventname')
all_funnels[:4]
Пользователей в каждой группе: A246 - 2484, A247 - 2513, B248 - 2537
| eventname | hash_uniq | all_ratio | prev_ratio | uniq246 | A246_ratio | uniq247 | A247_ratio | uniq248 | B248_ratio | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 98.473586 | 100.000000 | 2450 | 0.986312 | 2476 | 0.985277 | 2493 | 0.982657 |
| 1 | OffersScreenAppear | 4593 | 60.963632 | 61.908613 | 1542 | 0.620773 | 1520 | 0.604855 | 1531 | 0.603469 |
| 2 | CartScreenAppear | 3734 | 49.561986 | 81.297627 | 1266 | 0.509662 | 1238 | 0.492638 | 1230 | 0.484825 |
| 3 | PaymentScreenSuccessful | 3539 | 46.973719 | 94.777718 | 1200 | 0.483092 | 1158 | 0.460804 | 1181 | 0.465510 |
Статистические критерии в контрольных группах: различие в объемах и в пропорциях >1%. Немного превышены оба критерия - возможно эксперимент рано остановили.
#Функция проверки достоверности
def z_test(n1, n2, prop1, prop2, alpha=.05):
p_combined = (n1*prop1 + n2*prop2) / (n1 + n2)
difference = prop1 - prop2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/n1 + 1/n2))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('значимость', alpha, ' p-значение:', round(p_value, 4))
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
В каждом тесте проверяем нулевую гипотезу о равенстве конверсий(долей), альтернативной гипотезой будет различие долей. (Эта формулировка гипотез касается всех проводимых далее тестов)
Вообще, переход от старта до Главной страницы - это не шаг воронки, а потери при сборе и обрезании данных, поэтому правильнее было принять Главную страницу за старт
print('Для каждого шага воронки A246/A247 тест:')
alph = .05
for evnt in range(0, len(all_funnels)-1):
print('------------------------------------------')
print('Событие: переход на ', all_funnels.loc[evnt, 'eventname'])
print('------------------------------------------')
z_test(uniq_hash246, uniq_hash247, all_funnels.loc[evnt, 'A246_ratio'], all_funnels.loc[evnt, 'A247_ratio'], alph)
Для каждого шага воронки A246/A247 тест: ------------------------------------------ Событие: переход на MainScreenAppear ------------------------------------------ значимость 0.05 p-значение: 0.7571 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на OffersScreenAppear ------------------------------------------ значимость 0.05 p-значение: 0.2481 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на CartScreenAppear ------------------------------------------ значимость 0.05 p-значение: 0.2288 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на PaymentScreenSuccessful ------------------------------------------ значимость 0.05 p-значение: 0.1146 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Между контрольными группами A(246) и A(247) не наблюдается статистически значимых отличий. Можно считать, что данные для анализа корректны и применимы для сравнения контрольных групп с экспериментальной.
print('Для каждого шага воронки A246/B248 тест:')
alph = .01
for evnt in range(0, len(all_funnels)-1):
print('------------------------------------------')
print('Событие: переход на ', all_funnels.loc[evnt, 'eventname'])
print('------------------------------------------')
z_test(uniq_hash246, uniq_hash248, all_funnels.loc[evnt, 'A246_ratio'], all_funnels.loc[evnt, 'B248_ratio'], alph)
Для каждого шага воронки A246/B248 тест: ------------------------------------------ Событие: переход на MainScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.295 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на OffersScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.2084 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на CartScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.0784 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на PaymentScreenSuccessful ------------------------------------------ значимость 0.01 p-значение: 0.2123 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Между контрольной A(246) и экспериментальной В(248) группами не наблюдается статистически значимых отличий в долях. Нет оснований считать конверсии разными.
print('Для каждого шага воронки A247/B248 тест:')
alph = .01
for evnt in range(0, len(all_funnels)-1):
print('------------------------------------------')
print('Событие: переход на ', all_funnels.loc[evnt, 'eventname'])
print('------------------------------------------')
z_test(uniq_hash247, uniq_hash248, all_funnels.loc[evnt, 'A247_ratio'], all_funnels.loc[evnt, 'B248_ratio'], alph)
Для каждого шага воронки A247/B248 тест: ------------------------------------------ Событие: переход на MainScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.4587 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на OffersScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.9198 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на CartScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.5786 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на PaymentScreenSuccessful ------------------------------------------ значимость 0.01 p-значение: 0.7373 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Между контрольной A(247) и экспериментальной В(248) группами не наблюдается статистически значимых отличий в долях. Нет оснований считать конверсии разными.
print('Для каждого шага воронки (A246+A247)/B248 тест:')
alph = .01
for evnt in range(0, len(all_funnels)-1):
print('------------------------------------------')
print('Событие: переход на ', all_funnels.loc[evnt, 'eventname'])
print('------------------------------------------')
prop_comb = (all_funnels.loc[evnt, 'uniq246'] + all_funnels.loc[evnt, 'uniq247'])/(uniq_hash246+uniq_hash247)
z_test((uniq_hash246+uniq_hash247), uniq_hash248, prop_comb, all_funnels.loc[evnt, 'B248_ratio'], alph)
Для каждого шага воронки (A246+A247)/B248 тест: ------------------------------------------ Событие: переход на MainScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.2942 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на OffersScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.4343 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на CartScreenAppear ------------------------------------------ значимость 0.01 p-значение: 0.1818 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ------------------------------------------ Событие: переход на PaymentScreenSuccessful ------------------------------------------ значимость 0.01 p-значение: 0.6004 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Между объединенной контрольной A(246)+A(247) и экспериментальной В(248) группами не наблюдается статистически значимых отличий в долях. Нет оснований считать конверсии разными.
MainScreenAppear, PaymentScreenSuccessful, CartScreenAppear, OffersScreenAppear, Tutorial для 7500 пользователей, разбитых на три равные группы по 2500 пользователей,MainScreenAppear,MainScreenAppear на OffersScreenAppear,Tutorial не является обязательным и исключено из воронки событий,Уровень статистической значимости в тесте контрольных групп выбран 0.05, чтобы уменьшить вероятность ложноотрицательного множественного теста. А в сравнении с экспериментальной группой, наоборот, уровень значимости 0.01, чтобы уменьшить ложноположительный результат, вероятность которого при 4-х тестах составила 4%. В любом случае абсолютные вероятности далеки от статистической значимости, следовательно:
Значимых отличий экспериментальной группы от контрольных не обнаружено. Шрифты в приложении не влияют на поведение покупателей продуктов.